經過 Day 24 的 Landing Page 建置,我們已經有了吸引使用者的門面。今天我們要實作用戶認證系統的前端部分,包括登入/註冊表單、Token 管理、Protected Routes、以及完整的認證流程。這是從訪客轉換為付費用戶的關鍵部分。
/**
* 前端認證系統架構
*
* ┌─────────────────────────────────────────────┐
* │ User Authentication Flow │
* └─────────────────────────────────────────────┘
*
* 1. 訪客訪問 Landing Page
* ↓
* 2. 點擊「免費開始使用」
* ↓
* 3. 進入註冊流程
* ├─ 信箱驗證
* ├─ 密碼設定
* └─ 帳號啟用
* ↓
* 4. 登入系統
* ├─ 取得 Access Token
* └─ 取得 Refresh Token
* ↓
* 5. 存儲 Token (httpOnly cookie + localStorage)
* ↓
* 6. 自動附加 Token 到 API 請求
* ↓
* 7. Token 過期自動刷新
* ↓
* 8. 登出清除 Token
*
* 安全考量:
* ✅ Access Token: 短期 (15分鐘),存 localStorage
* ✅ Refresh Token: 長期 (7天),存 httpOnly cookie
* ✅ CSRF Protection
* ✅ XSS Prevention
* ✅ 密碼強度驗證
* ✅ Rate Limiting
*/
// src/contexts/AuthContext.tsx
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
ReactNode,
} from 'react';
import { useNavigate } from 'react-router-dom';
export interface User {
id: string;
email: string;
name: string;
avatar?: string;
role: 'admin' | 'user' | 'viewer';
tenantId: string;
emailVerified: boolean;
createdAt: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
interface AuthContextValue {
// 狀態
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
// 方法
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (data: RegisterData) => Promise<void>;
updateProfile: (data: Partial<User>) => Promise<void>;
refreshAccessToken: () => Promise<string>;
clearError: () => void;
}
interface RegisterData {
email: string;
password: string;
name: string;
tenantName?: string;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const isAuthenticated = !!user;
/**
* 從 localStorage 取得 Access Token
*/
const getAccessToken = useCallback((): string | null => {
return localStorage.getItem('accessToken');
}, []);
/**
* 儲存 Access Token
*/
const setAccessToken = useCallback((token: string) => {
localStorage.setItem('accessToken', token);
}, []);
/**
* 清除 Access Token
*/
const clearAccessToken = useCallback(() => {
localStorage.removeItem('accessToken');
}, []);
/**
* API 請求封裝
*/
const apiRequest = useCallback(
async <T,>(
endpoint: string,
options: RequestInit = {}
): Promise<T> => {
const token = getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers,
credentials: 'include', // 包含 cookies (Refresh Token)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Request failed');
}
return response.json();
},
[getAccessToken]
);
/**
* 刷新 Access Token
*/
const refreshAccessToken = useCallback(async (): Promise<string> => {
try {
const data = await apiRequest<{ accessToken: string }>(
'/api/auth/refresh',
{
method: 'POST',
}
);
setAccessToken(data.accessToken);
return data.accessToken;
} catch (err) {
// Refresh Token 也過期了,需要重新登入
clearAccessToken();
setUser(null);
throw err;
}
}, [apiRequest, setAccessToken, clearAccessToken]);
/**
* 取得當前用戶資訊
*/
const fetchCurrentUser = useCallback(async () => {
try {
const data = await apiRequest<{ user: User }>('/api/auth/me');
setUser(data.user);
} catch (err) {
// Access Token 無效,嘗試刷新
try {
await refreshAccessToken();
const data = await apiRequest<{ user: User }>('/api/auth/me');
setUser(data.user);
} catch (refreshErr) {
// 刷新失敗,清除狀態
clearAccessToken();
setUser(null);
}
} finally {
setIsLoading(false);
}
}, [apiRequest, refreshAccessToken, clearAccessToken]);
/**
* 登入
*/
const login = useCallback(
async (email: string, password: string) => {
try {
setIsLoading(true);
setError(null);
const data = await apiRequest<{
user: User;
accessToken: string;
}>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
setAccessToken(data.accessToken);
setUser(data.user);
// 重定向到 Dashboard
navigate('/dashboard');
} catch (err: any) {
setError(err.message || '登入失敗');
throw err;
} finally {
setIsLoading(false);
}
},
[apiRequest, setAccessToken, navigate]
);
/**
* 註冊
*/
const register = useCallback(
async (data: RegisterData) => {
try {
setIsLoading(true);
setError(null);
const response = await apiRequest<{
user: User;
accessToken: string;
message: string;
}>('/api/auth/register', {
method: 'POST',
body: JSON.stringify(data),
});
setAccessToken(response.accessToken);
setUser(response.user);
// 重定向到 Dashboard
navigate('/dashboard');
} catch (err: any) {
setError(err.message || '註冊失敗');
throw err;
} finally {
setIsLoading(false);
}
},
[apiRequest, setAccessToken, navigate]
);
/**
* 登出
*/
const logout = useCallback(async () => {
try {
setIsLoading(true);
// 呼叫後端登出 API (清除 Refresh Token)
await apiRequest('/api/auth/logout', {
method: 'POST',
});
} catch (err) {
console.error('Logout API failed:', err);
} finally {
// 清除前端狀態
clearAccessToken();
setUser(null);
setIsLoading(false);
// 重定向到首頁
navigate('/');
}
}, [apiRequest, clearAccessToken, navigate]);
/**
* 更新個人資料
*/
const updateProfile = useCallback(
async (updates: Partial<User>) => {
try {
setIsLoading(true);
setError(null);
const data = await apiRequest<{ user: User }>('/api/auth/profile', {
method: 'PATCH',
body: JSON.stringify(updates),
});
setUser(data.user);
} catch (err: any) {
setError(err.message || '更新失敗');
throw err;
} finally {
setIsLoading(false);
}
},
[apiRequest]
);
/**
* 清除錯誤
*/
const clearError = useCallback(() => {
setError(null);
}, []);
/**
* 初始化:檢查是否已登入
*/
useEffect(() => {
const token = getAccessToken();
if (token) {
fetchCurrentUser();
} else {
setIsLoading(false);
}
}, [getAccessToken, fetchCurrentUser]);
/**
* 設定 Token 自動刷新
*/
useEffect(() => {
if (!isAuthenticated) return;
// 每 14 分鐘刷新一次 (Access Token 15 分鐘過期)
const intervalId = setInterval(
async () => {
try {
await refreshAccessToken();
} catch (err) {
console.error('Auto refresh failed:', err);
}
},
14 * 60 * 1000
);
return () => clearInterval(intervalId);
}, [isAuthenticated, refreshAccessToken]);
const value: AuthContextValue = {
user,
isAuthenticated,
isLoading,
error,
login,
logout,
register,
updateProfile,
refreshAccessToken,
clearError,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// src/pages/Auth/LoginPage.tsx
import { useState } from 'react';
import {
Container,
Paper,
Title,
Text,
TextInput,
PasswordInput,
Button,
Group,
Anchor,
Stack,
Divider,
Alert,
} from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { z } from 'zod';
import { IconAlertCircle, IconBrandGoogle, IconBrandGithub } from '@tabler/icons-react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
const loginSchema = z.object({
email: z.string().email('請輸入有效的電子郵件'),
password: z.string().min(8, '密碼至少需要 8 個字元'),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export function LoginPage() {
const { login, error, clearError } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const redirectTo = searchParams.get('redirect') || '/dashboard';
const form = useForm<LoginFormValues>({
validate: zodResolver(loginSchema),
initialValues: {
email: '',
password: '',
},
});
const handleSubmit = async (values: LoginFormValues) => {
try {
setIsLoading(true);
clearError();
await login(values.email, values.password);
// 登入成功會自動重定向到 Dashboard
} catch (err) {
console.error('Login failed:', err);
} finally {
setIsLoading(false);
}
};
return (
<Container size={420} my={80}>
<Paper radius="md" p="xl" withBorder>
<Title order={2} ta="center" mb="md">
歡迎回來
</Title>
<Text c="dimmed" size="sm" ta="center" mb="xl">
還沒有帳號?{' '}
<Anchor component={Link} to="/register" size="sm">
建立帳號
</Anchor>
</Text>
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
title="登入失敗"
color="red"
mb="md"
onClose={clearError}
withCloseButton
>
{error}
</Alert>
)}
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack spacing="md">
<TextInput
label="電子郵件"
placeholder="your@email.com"
required
{...form.getInputProps('email')}
/>
<PasswordInput
label="密碼"
placeholder="您的密碼"
required
{...form.getInputProps('password')}
/>
<Group position="apart">
<Anchor component={Link} to="/forgot-password" size="sm">
忘記密碼?
</Anchor>
</Group>
<Button type="submit" fullWidth loading={isLoading}>
登入
</Button>
</Stack>
</form>
<Divider label="或使用以下方式登入" labelPosition="center" my="lg" />
<Stack spacing="sm">
<Button
variant="default"
leftIcon={<IconBrandGoogle size={18} />}
onClick={() => {
window.location.href = `${import.meta.env.VITE_API_URL}/api/auth/google`;
}}
>
使用 Google 登入
</Button>
<Button
variant="default"
leftIcon={<IconBrandGithub size={18} />}
onClick={() => {
window.location.href = `${import.meta.env.VITE_API_URL}/api/auth/github`;
}}
>
使用 GitHub 登入
</Button>
</Stack>
</Paper>
</Container>
);
}
// src/pages/Auth/RegisterPage.tsx
import { useState } from 'react';
import {
Container,
Paper,
Title,
Text,
TextInput,
PasswordInput,
Button,
Anchor,
Stack,
Alert,
Progress,
Group,
Checkbox,
} from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { z } from 'zod';
import { IconAlertCircle, IconCheck, IconX } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
const registerSchema = z
.object({
name: z.string().min(2, '名稱至少需要 2 個字元'),
email: z.string().email('請輸入有效的電子郵件'),
password: z
.string()
.min(8, '密碼至少需要 8 個字元')
.regex(/[A-Z]/, '密碼必須包含至少一個大寫字母')
.regex(/[a-z]/, '密碼必須包含至少一個小寫字母')
.regex(/[0-9]/, '密碼必須包含至少一個數字')
.regex(/[^A-Za-z0-9]/, '密碼必須包含至少一個特殊字元'),
confirmPassword: z.string(),
tenantName: z.string().optional(),
acceptTerms: z.boolean().refine((val) => val === true, {
message: '您必須同意服務條款',
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: '密碼不一致',
path: ['confirmPassword'],
});
type RegisterFormValues = z.infer<typeof registerSchema>;
export function RegisterPage() {
const { register, error, clearError } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<RegisterFormValues>({
validate: zodResolver(registerSchema),
initialValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
tenantName: '',
acceptTerms: false,
},
});
const handleSubmit = async (values: RegisterFormValues) => {
try {
setIsLoading(true);
clearError();
await register({
name: values.name,
email: values.email,
password: values.password,
tenantName: values.tenantName,
});
// 註冊成功會自動重定向到 Dashboard
} catch (err) {
console.error('Register failed:', err);
} finally {
setIsLoading(false);
}
};
// 密碼強度計算
const getPasswordStrength = (password: string): number => {
let strength = 0;
if (password.length >= 8) strength += 25;
if (/[A-Z]/.test(password)) strength += 25;
if (/[a-z]/.test(password)) strength += 25;
if (/[0-9]/.test(password)) strength += 12.5;
if (/[^A-Za-z0-9]/.test(password)) strength += 12.5;
return strength;
};
const passwordStrength = getPasswordStrength(form.values.password);
const getPasswordStrengthColor = (strength: number): string => {
if (strength < 40) return 'red';
if (strength < 70) return 'yellow';
return 'green';
};
return (
<Container size={480} my={80}>
<Paper radius="md" p="xl" withBorder>
<Title order={2} ta="center" mb="md">
建立帳號
</Title>
<Text c="dimmed" size="sm" ta="center" mb="xl">
已經有帳號了?{' '}
<Anchor component={Link} to="/login" size="sm">
登入
</Anchor>
</Text>
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
title="註冊失敗"
color="red"
mb="md"
onClose={clearError}
withCloseButton
>
{error}
</Alert>
)}
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack spacing="md">
<TextInput
label="姓名"
placeholder="您的名字"
required
{...form.getInputProps('name')}
/>
<TextInput
label="電子郵件"
placeholder="your@email.com"
required
{...form.getInputProps('email')}
/>
<TextInput
label="組織名稱(選填)"
placeholder="您的公司或團隊名稱"
{...form.getInputProps('tenantName')}
/>
<div>
<PasswordInput
label="密碼"
placeholder="設定您的密碼"
required
{...form.getInputProps('password')}
/>
{form.values.password && (
<div style={{ marginTop: 8 }}>
<Progress
value={passwordStrength}
color={getPasswordStrengthColor(passwordStrength)}
size="xs"
mb={4}
/>
<Text size="xs" c="dimmed">
密碼強度:
{passwordStrength < 40 && ' 弱'}
{passwordStrength >= 40 && passwordStrength < 70 && ' 中等'}
{passwordStrength >= 70 && ' 強'}
</Text>
</div>
)}
</div>
<PasswordInput
label="確認密碼"
placeholder="再次輸入密碼"
required
{...form.getInputProps('confirmPassword')}
/>
{/* 密碼要求提示 */}
<Stack spacing={4}>
<Text size="xs" weight={500}>
密碼要求:
</Text>
<PasswordRequirement
met={form.values.password.length >= 8}
label="至少 8 個字元"
/>
<PasswordRequirement
met={/[A-Z]/.test(form.values.password)}
label="至少一個大寫字母"
/>
<PasswordRequirement
met={/[a-z]/.test(form.values.password)}
label="至少一個小寫字母"
/>
<PasswordRequirement
met={/[0-9]/.test(form.values.password)}
label="至少一個數字"
/>
<PasswordRequirement
met={/[^A-Za-z0-9]/.test(form.values.password)}
label="至少一個特殊字元"
/>
</Stack>
<Checkbox
label={
<>
我同意{' '}
<Anchor component={Link} to="/terms" target="_blank">
服務條款
</Anchor>{' '}
和{' '}
<Anchor component={Link} to="/privacy" target="_blank">
隱私政策
</Anchor>
</>
}
{...form.getInputProps('acceptTerms', { type: 'checkbox' })}
/>
<Button type="submit" fullWidth loading={isLoading}>
建立帳號
</Button>
</Stack>
</form>
</Paper>
</Container>
);
}
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<Group spacing={4} c={met ? 'green' : 'dimmed'}>
{met ? <IconCheck size={14} /> : <IconX size={14} />}
<Text size="xs">{label}</Text>
</Group>
);
}
// src/components/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { Center, Loader } from '@mantine/core';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRole?: 'admin' | 'user' | 'viewer';
}
export function ProtectedRoute({ children, requiredRole }: ProtectedRouteProps) {
const { isAuthenticated, isLoading, user } = useAuth();
const location = useLocation();
// 載入中
if (isLoading) {
return (
<Center style={{ height: '100vh' }}>
<Loader size="lg" />
</Center>
);
}
// 未登入,重定向到登入頁
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
// 檢查角色權限
if (requiredRole && user) {
const roleHierarchy = { viewer: 0, user: 1, admin: 2 };
const userRoleLevel = roleHierarchy[user.role];
const requiredRoleLevel = roleHierarchy[requiredRole];
if (userRoleLevel < requiredRoleLevel) {
return <Navigate to="/unauthorized" replace />;
}
}
return <>{children}</>;
}
// src/lib/api-client.ts
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
/**
* 建立 Axios 實例
*/
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
withCredentials: true, // 包含 cookies
headers: {
'Content-Type': 'application/json',
},
});
/**
* Request Interceptor
* 自動附加 Access Token
*/
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
/**
* Response Interceptor
* 自動處理 Token 過期
*/
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: any) => void;
reject: (reason?: any) => void;
}> = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
apiClient.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
// 如果是 401 且不是 refresh 端點,嘗試刷新 Token
if (
error.response?.status === 401 &&
!originalRequest._retry &&
originalRequest.url !== '/api/auth/refresh'
) {
if (isRefreshing) {
// 如果正在刷新,將請求加入隊列
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// 呼叫 refresh 端點
const response = await apiClient.post('/api/auth/refresh');
const { accessToken } = response.data;
// 更新 localStorage
localStorage.setItem('accessToken', accessToken);
// 處理隊列中的請求
processQueue(null, accessToken);
// 重試原始請求
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
// Refresh 失敗,清除 Token 並重定向
processQueue(refreshError, null);
localStorage.removeItem('accessToken');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default apiClient;
// src/hooks/useIdleTimeout.ts
import { useEffect, useRef, useCallback } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { notifications } from '@mantine/notifications';
interface UseIdleTimeoutOptions {
idleTime?: number; // 閒置時間(毫秒)
warningTime?: number; // 警告時間(毫秒)
onIdle?: () => void;
onActive?: () => void;
}
/**
* 偵測用戶閒置並自動登出
*/
export function useIdleTimeout(options: UseIdleTimeoutOptions = {}) {
const {
idleTime = 30 * 60 * 1000, // 預設 30 分鐘
warningTime = 5 * 60 * 1000, // 預設 5 分鐘前警告
onIdle,
onActive,
} = options;
const { logout, isAuthenticated } = useAuth();
const timeoutId = useRef<number>();
const warningId = useRef<number>();
const lastActivityTime = useRef<number>(Date.now());
/**
* 重置計時器
*/
const resetTimer = useCallback(() => {
lastActivityTime.current = Date.now();
// 清除舊的計時器
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
if (warningId.current) {
clearTimeout(warningId.current);
}
// 設定警告計時器
warningId.current = window.setTimeout(() => {
notifications.show({
title: '即將登出',
message: '由於您已閒置一段時間,系統將在 5 分鐘後自動登出',
color: 'yellow',
autoClose: 10000,
});
}, idleTime - warningTime);
// 設定登出計時器
timeoutId.current = window.setTimeout(() => {
notifications.show({
title: '已自動登出',
message: '由於長時間未活動,您已被自動登出',
color: 'red',
});
logout();
onIdle?.();
}, idleTime);
onActive?.();
}, [idleTime, warningTime, logout, onIdle, onActive]);
useEffect(() => {
if (!isAuthenticated) return;
// 監聽的事件
const events = [
'mousedown',
'mousemove',
'keypress',
'scroll',
'touchstart',
'click',
];
// 節流:避免過於頻繁地重置計時器
let isThrottled = false;
const throttleTime = 1000; // 1 秒
const handleActivity = () => {
if (isThrottled) return;
isThrottled = true;
resetTimer();
setTimeout(() => {
isThrottled = false;
}, throttleTime);
};
// 綁定事件
events.forEach((event) => {
window.addEventListener(event, handleActivity);
});
// 初始化計時器
resetTimer();
// 清理
return () => {
events.forEach((event) => {
window.removeEventListener(event, handleActivity);
});
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
if (warningId.current) {
clearTimeout(warningId.current);
}
};
}, [isAuthenticated, resetTimer]);
return {
resetTimer,
lastActivityTime: lastActivityTime.current,
};
}
// src/utils/social-auth.ts
/**
* 社交登入處理
*
* OAuth 2.0 流程:
* 1. 前端重定向到 OAuth Provider
* 2. 用戶授權
* 3. Provider 重定向回 callback URL
* 4. 後端交換 code 取得 access token
* 5. 取得用戶資訊並建立/登入帳號
* 6. 返回前端 JWT token
*/
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
/**
* Google 登入
*/
export function loginWithGoogle() {
const callbackUrl = encodeURIComponent(`${window.location.origin}/auth/callback/google`);
window.location.href = `${API_BASE_URL}/api/auth/google?redirect=${callbackUrl}`;
}
/**
* GitHub 登入
*/
export function loginWithGithub() {
const callbackUrl = encodeURIComponent(`${window.location.origin}/auth/callback/github`);
window.location.href = `${API_BASE_URL}/api/auth/github?redirect=${callbackUrl}`;
}
/**
* 處理 OAuth Callback
*/
export function handleOAuthCallback(
provider: 'google' | 'github',
searchParams: URLSearchParams
): {
success: boolean;
accessToken?: string;
error?: string;
} {
const accessToken = searchParams.get('access_token');
const error = searchParams.get('error');
if (error) {
return { success: false, error };
}
if (accessToken) {
// 儲存 Token
localStorage.setItem('accessToken', accessToken);
return { success: true, accessToken };
}
return { success: false, error: 'No token received' };
}
// src/pages/Auth/OAuthCallbackPage.tsx
import { useEffect } from 'react';
import { useNavigate, useSearchParams, useParams } from 'react-router-dom';
import { Center, Loader, Text, Stack } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { handleOAuthCallback } from '../../utils/social-auth';
import { useAuth } from '../../contexts/AuthContext';
export function OAuthCallbackPage() {
const { provider } = useParams<{ provider: 'google' | 'github' }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { refreshAccessToken } = useAuth();
useEffect(() => {
if (!provider) {
navigate('/login');
return;
}
const result = handleOAuthCallback(provider, searchParams);
if (result.success && result.accessToken) {
// 取得用戶資訊
refreshAccessToken()
.then(() => {
notifications.show({
title: '登入成功',
message: `歡迎使用 ${provider === 'google' ? 'Google' : 'GitHub'} 登入`,
color: 'green',
});
navigate('/dashboard');
})
.catch((err) => {
console.error('Failed to fetch user:', err);
navigate('/login');
});
} else {
notifications.show({
title: '登入失敗',
message: result.error || '發生未知錯誤',
color: 'red',
});
navigate('/login');
}
}, [provider, searchParams, navigate, refreshAccessToken]);
return (
<Center style={{ height: '100vh' }}>
<Stack align="center">
<Loader size="lg" />
<Text>正在完成登入...</Text>
</Stack>
</Center>
);
}
我們今天完成了用戶認證系統的前端實作:
Token 存儲策略:
Token 刷新機制:
密碼強度驗證:
閒置超時策略: